Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Signed requests #45979

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open

Signed requests #45979

wants to merge 5 commits into from

Conversation

ArtificialOwl
Copy link
Member

@ArtificialOwl ArtificialOwl commented Jun 19, 2024

Signed Request

Signing request allows to confirm the identity of the sender.
Signing request does not encrypt nor affect its payload.
Signing request only adds metadata to the headers of the request.

Signature

The concept is to add unique metadata and sign them using a private/public key pair.
The location of the public key used to verify the signature will confirm the origin of the request.

Signature does not affect the data of the request, it only adds headers to it:

 {
     "(request-target)": "post /path",
     "content-length": 380,
     "date": "Mon, 08 Jul 2024 14:16:20 GMT",
     "digest": "SHA-256=U7gNVUQiixe5BRbp4Tg0xCZMTcSWXXUZI2\\/xtHM40S0=",
     "host": "hostname.of.the.recipient",
     "Signature": "keyId=\"https://author.hostname/ocm#signature\",algorithm=\"sha256\",headers=\"request-target) content-length date digest host\",signature=\"DzN12OCS1rsA[...]o0VmxjQooRo6HHabg==\""
 }
  • 'content-length' is the total length of the data/content
  • 'date' is the datetime the request have been initiated
  • 'digest' is a checksum of the data/content
  • 'host' is the hostname of the recipient of the request (remote when signing outgoing request, local on incoming request)
  • 'Signature' contains the signature generated using the private key, and metadata:
    • 'keyId' is a unique id, formatted as an url. hostname is used to retrieve the public key via custom discovery
    • 'algorithm' define the algorithm used to generate signature
    • 'headers' contains a list of element used during the generation of the signature
    • 'signature' is the encrypted string, using local private key, of an array containing elements
      listed in 'headers' and their value. Some elements (content-length date digest host) are mandatory
      to ensure authenticity override protection.

(Those are the minimum required headers, some can be added via options during the process)

ISignatoryManager

Because each protocol have different ways to obtain the public key of a remote instance or entity, some part of the signing/verifying process is managed by a custom provider, one for each protocol.

  • getProviderId should returns a unique string

  • getOptions can returns an array that can contains those entries:

    • 'ttl' (300) is the lifetime (in secondes) of the signature
    • 'ttlSignatory' (86400*3) the cache lifetime on a remote signatory
    • 'extraSignatureHeaders' ([]) (list of extra headers to include to the signature)
    • 'algorithm' ('sha256') is the algorithm used to generate the signature
    • 'dateHeader' ("D, d M Y H:i:s T") the format of the 'date' header
  • getLocalSignatory should return the local signatory, including the full (public+private) key pair.

  • getRemoteSignatory should returns a remote signatory based on the requested data, must at least contains key id and public key

ISignatureManager

ISignatureManager is a service integrated to core that provide tools to set/get authenticity of/from outgoing/incoming requests.

  • getIncomingSignedRequest extract data from the incoming request and compare headers to confirm authenticity of remote instance
  • getOutgoingSignedRequest prep signature to sign an outgoing request.
  • signOutgoingRequestIClientPayload is the one method to call to fully process of signing and fulfilling the payload for an outgoing request using IClient
  • searchSignatory get a remote signatory from the database

lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed
lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed
lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed
lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed
lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed

[$k, $v] = explode('=', $entry, 2);
preg_match('/"([^"]+)"/', $v, $varr);
if ($varr[0] !== null) {

Check failure

Code scanning / Psalm

RedundantCondition

string can never contain null
lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed
lib/private/OCM/OCMSignatoryManager.php Fixed Show fixed Hide fixed

$parsed = parse_url($uri);
$signedRequest->setHost($parsed['host'])
->setAlgorithm($options['algorithm'] ?? 'sha256')

Check failure

Code scanning / Psalm

UndefinedVariable

Cannot find referenced variable $options
apps/files_sharing/lib/External/Cache.php Fixed Show fixed Hide fixed
// remote does not support signed request.
// currently we still accept unsigned request until lazy appconfig
// core.enforce_signed_ocm_request is set to true (default: false)
if ($this->appConfig->getValueBool('enforce_signed_ocm_request', false, lazy: true)) {

Check failure

Code scanning / Psalm

InvalidArgument

Argument 2 of OCP\IAppConfig::getValueBool cannot be false, string value expected
if ($signatory === null) {
throw new SignatoryNotFoundException('empty result from getRemoteSignatory');
}
if ($signatory->getKeyId() !== $signedRequest->getKeyId()) {

Check failure

Code scanning / Psalm

UndefinedInterfaceMethod

Method OCP\Security\Signature\Model\IIncomingSignedRequest::getKeyId does not exist
if (!str_contains($entry, '@')) {
throw new IncomingRequestException('entry does not contains @');
}
[, $instance] = explode('@', $entry, 2);

Check notice

Code scanning / Psalm

PossiblyUndefinedArrayOffset

Possibly undefined array key
* @inheritDoc
*
* @param string $publicKey
* @return IKeyPair

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\PublicPrivateKeyPairs\Model\IKeyPair', should be 'OC\Security\PublicPrivateKeyPairs\Model\KeyPair'
* @inheritDoc
*
* @param string $privateKey
* @return IKeyPair

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\PublicPrivateKeyPairs\Model\IKeyPair', should be 'OC\Security\PublicPrivateKeyPairs\Model\KeyPair'
* @inheritDoc
*
* @param array $options
* @return IKeyPair

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\PublicPrivateKeyPairs\Model\IKeyPair', should be 'OC\Security\PublicPrivateKeyPairs\Model\KeyPair'
* @inheritDoc
*
* @param string $host
* @return IOutgoingSignedRequest

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\Signature\Model\IOutgoingSignedRequest', should be 'OC\Security\Signature\Model\OutgoingSignedRequest'
* @param string $key
* @param string|int|float|bool|array $value
*
* @return IOutgoingSignedRequest

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\Signature\Model\IOutgoingSignedRequest', should be 'OC\Security\Signature\Model\OutgoingSignedRequest'
*
* @param string $estimated
*
* @return IOutgoingSignedRequest

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\Signature\Model\IOutgoingSignedRequest', should be 'OC\Security\Signature\Model\OutgoingSignedRequest'
*
* @param string $algorithm
*
* @return IOutgoingSignedRequest

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\Signature\Model\IOutgoingSignedRequest', should be 'OC\Security\Signature\Model\OutgoingSignedRequest'
* @inheritDoc
*
* @param array $signatureHeader
* @return ISignedRequest

Check failure

Code scanning / Psalm

MismatchingDocblockReturnType

Docblock has incorrect return type 'OCP\Security\Signature\Model\ISignedRequest', should be 'OC\Security\Signature\Model\SignedRequest'
/**
* @inheritDoc
*
* @return ISignatory

Check failure

Code scanning / Psalm

InvalidNullableReturnType

The declared return type 'OCP\Security\Signature\Model\ISignatory' for OC\Security\Signature\Model\SignedRequest::getSignatory is not nullable, but 'OCP\Security\Signature\Model\ISignatory|null' contains null
* @since 30.0.0
*/
public function getSignatory(): ISignatory {
return $this->signatory;

Check failure

Code scanning / Psalm

NullableReturnStatement

The declared return type 'OCP\Security\Signature\Model\ISignatory' for OC\Security\Signature\Model\SignedRequest::getSignatory is not nullable, but the function returns 'OCP\Security\Signature\Model\ISignatory|null'
@ArtificialOwl ArtificialOwl added the 3. to review Waiting for reviews label Jul 10, 2024
@ArtificialOwl ArtificialOwl added this to the Nextcloud 30 milestone Jul 10, 2024
@ArtificialOwl ArtificialOwl marked this pull request as ready for review July 10, 2024 16:24
to.json Outdated Show resolved Hide resolved
// if request is signed and well signed, no exception are thrown
// if request is not signed and host is known for not supporting signed request, no exception are thrown
$signedRequest = $this->getSignedRequest();
$this->confirmShareOrigin($signedRequest, $notification['sharedSecret'] ?? '');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sharedSecret inside the notification is already used by some OCM messages. So this would break it. Can you take another key? Or maybe you prefix with '$#$' or something alike?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would not break it as I send the exception for the exact same reason of a missing 'sharedSecret' entry in the notifications request. The only thing is that I do this check earlier than others but I can add a prefix if you want (while I dont feel like necessary myself)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would not break it as I send the exception for the exact same reason of a missing 'sharedSecret' entry in the notifications request.

Nextcloud Talk is sending a data field 'sharedSecret' and you either overwrite that and it breaks Talk Federation with older servers or you need to use a different key

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As said before, I only use this entry because it exists in the OCM protocol and doing so to compare that the origin of the reshare request, based on the token (and the linked recipient stored in the database), confirm the identity used to sign the request.

If Talk is using this endpoint to initiate anything, signature are to be required

lib/private/Federation/CloudFederationProviderManager.php Outdated Show resolved Hide resolved
lib/public/Security/Signature/SignatureAlgorithm.php Outdated Show resolved Hide resolved
lib/public/Security/Signature/Model/SignatoryType.php Outdated Show resolved Hide resolved
lib/private/Security/Signature/Model/SignedRequest.php Outdated Show resolved Hide resolved
lib/private/Security/Signature/SignatureManager.php Outdated Show resolved Hide resolved
core/Migrations/Version30000Date20240101084401.php Outdated Show resolved Hide resolved
}
}

private function insertSignatory(ISignatory $signatory): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By now our Entity+QBMapper pattern is widely adapted. Any reason why you didn't go for it and instead went back to doing all this manually again?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no real reason other that personal preference

lib/private/Security/Signature/SignatureManager.php Outdated Show resolved Hide resolved
This was referenced Jul 30, 2024
This was referenced Aug 5, 2024
@skjnldsv skjnldsv mentioned this pull request Aug 13, 2024
@skjnldsv skjnldsv modified the milestones: Nextcloud 30, Nextcloud 31 Aug 14, 2024
@skjnldsv skjnldsv added 2. developing Work in progress and removed 3. to review Waiting for reviews labels Oct 29, 2024
@ArtificialOwl ArtificialOwl force-pushed the enh/noid/signed-request branch 4 times, most recently from 444f9e7 to f3a1684 Compare October 31, 2024 12:21
@ArtificialOwl ArtificialOwl force-pushed the enh/noid/signed-request branch 6 times, most recently from df8f30e to 1b02a3c Compare November 18, 2024 00:56
@ArtificialOwl
Copy link
Member Author

Since last review, new commit:

  • switching to IdentityProof
  • removing features not fully related to this PR
  • fixing some edge use case on federationId,
  • fixing an issue to extract the origin of a notification on a reshare,
  • fixing test by adding threads to httpd,
  • fixing all comments/requests from previous reviews
  • upgrading version to trigger database migration

@ArtificialOwl ArtificialOwl added 3. to review Waiting for reviews and removed 2. developing Work in progress labels Nov 18, 2024
@ArtificialOwl ArtificialOwl force-pushed the enh/noid/signed-request branch 6 times, most recently from bcb284a to ab72b03 Compare November 21, 2024 10:26
Copy link
Contributor

@come-nc come-nc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be a mapper for signatory
There should not be a digest sent to openssl_* which is creating a digest by itself.
clearSignature should be renamed to something more appropriate.

Comment on lines +60 to +78
$date = $this->getRequest()->getHeader('date');
if ($date === '') {
throw new SignatureNotFoundException('missing date in header');
}
$contentLength = $this->getRequest()->getHeader('content-length');
if ($contentLength === '') {
throw new SignatureNotFoundException('missing content-length in header');
}
$digest = $this->getRequest()->getHeader('digest');
if ($digest === '') {
throw new SignatureNotFoundException('missing digest in header');
}
if ($this->getRequest()->getHeader('Signature') === '') {
throw new SignatureNotFoundException('missing Signature in header');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$date = $this->getRequest()->getHeader('date');
if ($date === '') {
throw new SignatureNotFoundException('missing date in header');
}
$contentLength = $this->getRequest()->getHeader('content-length');
if ($contentLength === '') {
throw new SignatureNotFoundException('missing content-length in header');
}
$digest = $this->getRequest()->getHeader('digest');
if ($digest === '') {
throw new SignatureNotFoundException('missing digest in header');
}
if ($this->getRequest()->getHeader('Signature') === '') {
throw new SignatureNotFoundException('missing Signature in header');
}
$date = $this->request->getHeader('date');
if ($date === '') {
throw new SignatureNotFoundException('missing date in header');
}
$contentLength = $this->request->getHeader('content-length');
if ($contentLength === '') {
throw new SignatureNotFoundException('missing content-length in header');
}
$digest = $this->request->getHeader('digest');
if ($digest === '') {
throw new SignatureNotFoundException('missing digest in header');
}
if ($this->request->getHeader('Signature') === '') {
throw new SignatureNotFoundException('missing Signature in header');
}

// confirm presence of date, content-length, digest and Signature
$date = $this->getRequest()->getHeader('date');
if ($date === '') {
throw new SignatureNotFoundException('missing date in header');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SignatureNotFoundException does not feel like the right exception here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All thrown in this method should be IncomingRequestException I think

* @inheritDoc
*
* @param string $host
* @return IOutgoingSignedRequest
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @return IOutgoingSignedRequest
* @return $this

You should use this for fluent interfaces in phpdoc.
Also you do not need it in the class when it’s there on the public interface it’s inherited by default.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'll have a look

private readonly string $body,
) {
// digest is created on the fly using $body
$this->digest = 'SHA-256=' . base64_encode(hash('sha256', utf8_encode($body), true));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utf8_encode is deprecated.
Do you really have the body in ISO-8859-1 here? Why?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'll replace utf8_encode but I have no way to know current encoding on the string (from what I know of)
I guess enforcing the encoding is best to ensure correct digest value

$estimated[] = $key . ': ' . $value;
}

$signedRequest->setClearSignature(implode("\n", $estimated));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still dislike that this is stored in the IIncomingSignedRequest while computed outside. Either there is only getClearSignature that computes the value, or the method is only in the signature manager and returns it, no need to set it on the object.

Also, I’m pretty sure this should be named an other way. "clearSignature" is confusing. But I’m not sure what is the common term for this. fingerprint? signatureData? signedData? requestData?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'll get a new term

private readonly string $body,
) {
// digest is created on the fly using $body
$this->digest = 'SHA-256=' . base64_encode(hash('sha256', utf8_encode($body), true));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually you should not compute a digest yourself. This is the job of openssl_sign and openssl_verify, and why you give them a sum algorithm to use.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not a fan of signing a document including part of content that could be generated from the outside. Using a checksum of the content (with a base64 encoding on top) makes everything more random. It also limit the size of the data sent to be signed which (I think) should be better (theoretically) to ensure the private key private

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is already what openssl_sign is doing. You do not need to prepare a hash before hand.
openssl_sign takes a hash method as a parameter, which it applies to the string before encryption to create the signature.
Why hash twice? It’s both a performance loss and a security issue because you rely on 2 hash methods and one of them being broken is enough.

Comment on lines +555 to +438
$knownSignatory = $this->getStoredSignatory($signatory->getKeyId());
switch ($signatory->getType()) {
case SignatoryType::FORGIVABLE:
$this->deleteSignatory($knownSignatory->getKeyId());
$this->insertSignatory($signatory);
return;

case SignatoryType::REFRESHABLE:
$this->updateSignatoryPublicKey($signatory);
$this->updateSignatoryMetadata($signatory);
break;

case SignatoryType::TRUSTED:
// TODO: send notice to admin
throw new SignatoryConflictException();

case SignatoryType::STATIC:
// TODO: send warning to admin
throw new SignatoryConflictException();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit overkill to have these 4 types, are they all used and needed? Is it an admin choice?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is set by the 3rd party ISignatoryManager that will decide how must be handle a change of the remote public key

@sorbaugh
Copy link
Contributor

sorbaugh commented Nov 26, 2024

Hello, due to the importance of having this issue merged, please let's postpone all approach-related optimization to later. If this PR checks this boxes, please let's approve and merge:

  • PR fixes the issue / adds the feature as intended
  • PR is backwards compatible (needs a config switch that's OFF by default, needs to work with the OCM ecosystem)

After that we should update the documentation about the config switch.

Thanks! 🙏

@ArtificialOwl ArtificialOwl force-pushed the enh/noid/signed-request branch 2 times, most recently from 031800c to 7a92f9c Compare November 28, 2024 13:35
Signed-off-by: Maxence Lange <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
3. to review Waiting for reviews
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants